From 43af74832f9a2fa7f40dc71985eec9b0ada087dd Mon Sep 17 00:00:00 2001 From: Kit La Touche Date: Tue, 11 Feb 2025 23:16:16 -0500 Subject: Merge local channel data and remote to maintain state When we hit the boot endpoint, we get the server's view of things. If we just setChannels with that, we overwrite all our locally-stored info in on things like lastReadAt. So we need to merge data. Eventually, this might militate for a `meta` key containing an object of locally stored data, rather than having to handle each key specially. --- ui/routes/(app)/ch/[channel]/+page.svelte | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) (limited to 'ui/routes/(app)/ch/[channel]') diff --git a/ui/routes/(app)/ch/[channel]/+page.svelte b/ui/routes/(app)/ch/[channel]/+page.svelte index dbdb507..d64a8c9 100644 --- a/ui/routes/(app)/ch/[channel]/+page.svelte +++ b/ui/routes/(app)/ch/[channel]/+page.svelte @@ -33,16 +33,10 @@ } function setLastRead() { - const channelObject = $channelsList.getChannel(channel); const lastInView = getLastVisibleMessage(); - if (!channelObject || !lastInView) { - return; - } - const at = DateTime.fromISO(lastInView.dataset.at); - // Do it this way, rather than with Math.max tricks, to avoid assignment - // when we don't need it, to minimize reactive changes: - if (at > channelObject.lastReadAt) { - channelObject.lastReadAt = at; + if (lastInView) { + const at = DateTime.fromISO(lastInView.dataset.at); + $channelsList.updateLastReadAt(channel, at); } } -- cgit v1.2.3 From daaf37a1ed3760f03fceb1123ebe80de3a2f280c Mon Sep 17 00:00:00 2001 From: Kit La Touche Date: Sun, 16 Feb 2025 22:00:57 -0500 Subject: Separate channel metadata out into its own store This is stored locally, and, while parallel to channel info, is not the same as. Eventually, this may hold info about moot/decayed channels, and grow unbounded. That'll need to be addressed. --- ui/lib/constants.js | 1 + ui/lib/store.js | 10 +- ui/lib/store/channels.svelte.js | 150 +++++++++++++++++------------- ui/routes/(app)/+layout.svelte | 10 +- ui/routes/(app)/ch/[channel]/+page.svelte | 4 +- 5 files changed, 102 insertions(+), 73 deletions(-) (limited to 'ui/routes/(app)/ch/[channel]') diff --git a/ui/lib/constants.js b/ui/lib/constants.js index a707c4b..c001f6d 100644 --- a/ui/lib/constants.js +++ b/ui/lib/constants.js @@ -1 +1,2 @@ export const STORE_KEY_CHANNELS_DATA = 'pilcrow:channelsData'; +export const EPOCH_STRING = '1970-01-01T00:00:00Z'; diff --git a/ui/lib/store.js b/ui/lib/store.js index f408c0c..508320f 100644 --- a/ui/lib/store.js +++ b/ui/lib/store.js @@ -1,13 +1,17 @@ import { writable } from 'svelte/store'; import { browser } from '$app/environment'; -import { Channels } from '$lib/store/channels.svelte.js'; +import { Channels, ChannelsMeta } from '$lib/store/channels.svelte.js'; import { Messages } from '$lib/store/messages.svelte.js'; import { Logins } from '$lib/store/logins'; +import { STORE_KEY_CHANNELS_DATA } from '$lib/constants'; // Get channelsList content from the local storage -const channelsData = (browser && JSON.parse(localStorage.getItem('pilcrow:channelsData'))) || {}; +const channelsMetaData = ( + browser && JSON.parse(localStorage.getItem(STORE_KEY_CHANNELS_DATA)) +) || {}; export const currentUser = writable(null); export const logins = writable(new Logins()); -export const channelsList = writable(new Channels({ channelsData })); +export const channelsMetaList = writable(new ChannelsMeta({ channelsMetaData })); +export const channelsList = writable(new Channels({ channelsMetaList })); export const messages = writable(new Messages()); diff --git a/ui/lib/store/channels.svelte.js b/ui/lib/store/channels.svelte.js index 49cc31c..86d924e 100644 --- a/ui/lib/store/channels.svelte.js +++ b/ui/lib/store/channels.svelte.js @@ -1,78 +1,28 @@ import { DateTime } from 'luxon'; -import { STORE_KEY_CHANNELS_DATA } from '$lib/constants'; -const EPOCH_STRING = '1970-01-01T00:00:00Z'; +import { get } from 'svelte/store' +import { STORE_KEY_CHANNELS_DATA, EPOCH_STRING } from '$lib/constants'; +// # Why we don't have a Channel object +// // For reasons unclear to me, a straight up class definition with a constructor // doesn't seem to work, reactively. So we resort to this. +// // Owen suggests that this sentence in the Svelte docs should make the reason // clear: +// // > If $state is used with an array or a simple object, the result is a deeply // > reactive state proxy. +// // Emphasis on "simple object". +// // --Kit -function makeChannelObject({ id, name, draft = '', lastReadAt = null, scrollPosition = null }) { - let lastReadAtParsed; - if (Boolean(lastReadAt)) { - if (typeof lastReadAt === "string") { - lastReadAtParsed = DateTime.fromISO(lastReadAt); - } else { - lastReadAtParsed = lastReadAt; - } - } else { - lastReadAtParsed = DateTime.fromISO(EPOCH_STRING); - } - return { - id, - name, - lastReadAt: lastReadAtParsed, - draft, - scrollPosition - }; -} - -function mergeLocalData(remoteData, currentData) { - let currentDataObj = currentData.reduce( - (acc, cur) => { - acc[cur.id] = cur; - return acc; - }, - {} - ); - const ret = remoteData.map( - (ch) => { - const newCh = makeChannelObject(ch); - if (Boolean(currentDataObj[ch.id])) { - newCh.lastReadAt = currentDataObj[ch.id].lastReadAt; - } - return newCh; - } - ); - return ret; -} export class Channels { channels = $state([]); - constructor({ channelsData }) { - this.channels = channelsData.map(makeChannelObject); - // On channel edits (inc 'last read' ones), write out to localstorage? - } - - writeOutToLocalStorage() { - localStorage.setItem( - STORE_KEY_CHANNELS_DATA, - JSON.stringify(this.channels) - ); - } - - updateLastReadAt(channelId, at) { - const channelObject = this.getChannel(channelId); - // Do it this way, rather than with Math.max tricks, to avoid assignment - // when we don't need it, to minimize reactive changes: - if (at > channelObject.lastReadAt) { - channelObject.lastReadAt = at; - this.writeOutToLocalStorage(); - } + constructor({ channelsMetaList }) { + // This is the state wrapper around the channels meta object. Dammit. + this.channelsMetaList = channelsMetaList; } getChannel(channelId) { @@ -80,16 +30,17 @@ export class Channels { } setChannels(channels) { - // This gets called, among other times, when the page is first loaded, with - // server-sent data from the `boot` endpoint. That needs to be merged with - // locally stored data! - this.channels = mergeLocalData(channels, this.channels); + // Because this is called at initialization, we need to initialize the matching meta: + get(this.channelsMetaList).ensureChannels(channels); + this.channels = channels; this.sort(); return this; } addChannel(id, name) { - this.channels = [...this.channels, makeChannelObject({ id, name })]; + const newChannel = { id, name } + this.channels = [...this.channels, newChannel]; + get(this.channelsMetaList).initializeChannel(newChannel); this.sort(); return this; } @@ -113,3 +64,70 @@ export class Channels { }); } } + +export class ChannelsMeta { + // Store channelId -> { draft = '', lastReadAt = null, scrollPosition = null } + channelsMeta = $state({}); + + constructor({ channelsMetaData }) { + const channelsMeta = objectMap(channelsMetaData, (ch) => { + let lastReadAt = ch.lastReadAt; + if (typeof lastReadAt === 'string') { + lastReadAt = DateTime.fromISO(lastReadAt); + } + if (!Boolean(lastReadAt)) { + lastReadAt = DateTime.fromISO(EPOCH_STRING); + } + return { + ...ch, + lastReadAt, + }; + }); + this.channelsMeta = channelsMeta; + } + + writeOutToLocalStorage() { + localStorage.setItem( + STORE_KEY_CHANNELS_DATA, + JSON.stringify(this.channelsMeta) + ); + } + + updateLastReadAt(channelId, at) { + const channelObject = this.getChannel(channelId); + // Do it this way, rather than with Math.max tricks, to avoid assignment + // when we don't need it, to minimize reactive changes: + if (at > channelObject?.lastReadAt) { + channelObject.lastReadAt = at; + this.writeOutToLocalStorage(); + } + } + + ensureChannels(channelsList) { + channelsList.forEach(({ id }) => { + this.initializeChannel(id); + }); + } + + initializeChannel(channelId) { + if (!this.channelsMeta[channelId]) { + const channelData = { + lastReadAt: null, + draft: '', + scrollPosition: null, + }; + this.channelsMeta[channelId] = channelData; + } + } + + getChannel(channelId) { + return this.channelsMeta[channelId] || null; + } +} + +function objectMap(object, mapFn) { + return Object.keys(object).reduce((result, key) => { + result[key] = mapFn(object[key]) + return result + }, {}); +} diff --git a/ui/routes/(app)/+layout.svelte b/ui/routes/(app)/+layout.svelte index cbfef54..9ade399 100644 --- a/ui/routes/(app)/+layout.svelte +++ b/ui/routes/(app)/+layout.svelte @@ -6,7 +6,7 @@ import TinyGesture from 'tinygesture'; import { boot, subscribeToEvents } from '$lib/apiServer'; - import { currentUser, logins, channelsList, messages } from '$lib/store'; + import { currentUser, logins, channelsList, channelsMetaList, messages } from '$lib/store'; import ChannelList from '$lib/components/ChannelList.svelte'; import CreateChannelForm from '$lib/components/CreateChannelForm.svelte'; @@ -23,6 +23,10 @@ channelsList.subscribe((val) => { rawChannels = val.channels; }); + let rawChannelsMeta; + channelsMetaList.subscribe((val) => { + rawChannelsMeta = val.channelsMeta; + }); let rawMessages; messages.subscribe((val) => { rawMessages = val; @@ -30,7 +34,9 @@ let enrichedChannels = $derived.by(() => { const channels = rawChannels; + const channelsMeta = rawChannelsMeta; const messages = rawMessages; + const enrichedChannels = []; if (channels && messages) { for (let ch of channels) { @@ -38,7 +44,7 @@ let lastRun = runs?.slice(-1)[0]; let lastMessage = lastRun?.messages.slice(-1)[0]; let lastMessageAt = lastMessage?.at; - let hasUnreads = lastMessageAt > ch.lastReadAt; + let hasUnreads = lastMessageAt > channelsMeta[ch.id]?.lastReadAt; enrichedChannels.push({ ...ch, hasUnreads diff --git a/ui/routes/(app)/ch/[channel]/+page.svelte b/ui/routes/(app)/ch/[channel]/+page.svelte index d64a8c9..7bd0e10 100644 --- a/ui/routes/(app)/ch/[channel]/+page.svelte +++ b/ui/routes/(app)/ch/[channel]/+page.svelte @@ -3,7 +3,7 @@ import { page } from '$app/stores'; import ActiveChannel from '$lib/components/ActiveChannel.svelte'; import MessageInput from '$lib/components/MessageInput.svelte'; - import { channelsList, messages } from '$lib/store'; + import { channelsMetaList, messages } from '$lib/store'; let channel = $derived($page.params.channel); let messageRuns = $derived($messages.inChannel(channel)); @@ -36,7 +36,7 @@ const lastInView = getLastVisibleMessage(); if (lastInView) { const at = DateTime.fromISO(lastInView.dataset.at); - $channelsList.updateLastReadAt(channel, at); + $channelsMetaList.updateLastReadAt(channel, at); } } -- cgit v1.2.3 From 743b59b69857da81b214970ec9252bc918ad243d Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Thu, 20 Feb 2025 22:22:57 -0500 Subject: Only check for message visibility while a channel is actually attached to the DOM. Prevents this from breaking during DOM unmounting, when leaving a channel. --- ui/routes/(app)/ch/[channel]/+page.svelte | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) (limited to 'ui/routes/(app)/ch/[channel]') diff --git a/ui/routes/(app)/ch/[channel]/+page.svelte b/ui/routes/(app)/ch/[channel]/+page.svelte index 7bd0e10..25bc318 100644 --- a/ui/routes/(app)/ch/[channel]/+page.svelte +++ b/ui/routes/(app)/ch/[channel]/+page.svelte @@ -22,14 +22,15 @@ } function getLastVisibleMessage() { - const parentElement = activeChannel; - const childElements = parentElement.getElementsByClassName('message'); - const lastInView = Array.from(childElements) - .reverse() - .find((el) => { - return inView(parentElement, el); - }); - return lastInView; + if (activeChannel) { + const childElements = activeChannel.getElementsByClassName('message'); + const lastInView = Array.from(childElements) + .reverse() + .find((el) => { + return inView(activeChannel, el); + }); + return lastInView; + } } function setLastRead() { -- cgit v1.2.3